Preliminaries

Package requirements

We start by loading a couple of packages for data manipulation, dimension reduction and fancy representations.

library(tidyverse)   # advanced data manipulation and vizualisation
library(knitr)       # R notebook export and formatting 
library(FactoMineR)  # Factor analysis
library(factoextra)  # Fancy plotting of FactoMineR outputs
library(corrplot)    # Fancy plotting of matrices 
library(GGally)      # Easy-to-use ggplot2 extensions
library(ggpubr)
library(maps)
library(ggrepel)
theme_set(theme_bw()) # set default ggplot2 theme to black and white

Reminders on Multidimensional scaling (MDS)

There are different types of MDS algorithms, including

Classical multidimensional scaling Preserves the original distance metric, between points, as well as possible. That is the fitted distances on the MDS map and the original distances are in the same metric. Classic MDS belongs to the so-called metric multidimensional scaling category.

It is also known as principal coordinates analysis. It is suitable for quantitative data.

Non-metric multidimensional scaling It is also known as ordinal MDS. Here, it is not the metric of a distance value that is important or meaningful, but its value in relation to the distances between other pairs of objects.

Ordinal MDS constructs fitted distances that are in the same rank order as the original distance. For example, if the distance of apart objects \(1\) and \(5\) rank fifth in the original distance data, then they should also rank fifth in the MDS configuration.

It is suitable for qualitative data.

R functions for MDS

To perform MDS, we may either use:

cmdscale() [stats package]: Compute classical (metric) multidimensional scaling.

?cmdscale()

isoMDS() [MASS package]: Compute Kruskal's non-metric multidimensional scaling (one form of non-metric MDS).

?isoMDS()

sammon() [MASS package]: Compute Sammon's non-linear mapping (one form of non-metric MDS).

?sammon()

All of these functions take a distance object as main argument and a number \(k\) corresponding to the desired number of dimensions in the scaled output.

Classical MDS

We consider the swiss data that contains fertility and socio-economic data on 47 French speaking provinces in Switzerland.

data("swiss")
swiss %>% head(15)
##              Fertility Agriculture Examination Education Catholic
## Courtelary        80.2        17.0          15        12     9.96
## Delemont          83.1        45.1           6         9    84.84
## Franches-Mnt      92.5        39.7           5         5    93.40
## Moutier           85.8        36.5          12         7    33.77
## Neuveville        76.9        43.5          17        15     5.16
## Porrentruy        76.1        35.3           9         7    90.57
## Broye             83.8        70.2          16         7    92.85
## Glane             92.4        67.8          14         8    97.16
## Gruyere           82.4        53.3          12         7    97.67
## Sarine            82.9        45.2          16        13    91.38
## Veveyse           87.1        64.5          14         6    98.61
## Aigle             64.1        62.0          21        12     8.52
## Aubonne           66.9        67.5          14         7     2.27
## Avenches          68.9        60.7          19        12     4.43
## Cossonay          61.7        69.3          22         5     2.82
##              Infant.Mortality
## Courtelary               22.2
## Delemont                 22.2
## Franches-Mnt             20.2
## Moutier                  20.3
## Neuveville               20.6
## Porrentruy               26.6
## Broye                    23.6
## Glane                    24.9
## Gruyere                  21.0
## Sarine                   24.4
## Veveyse                  24.5
## Aigle                    16.5
## Aubonne                  19.1
## Avenches                 22.7
## Cossonay                 18.7
rownames(swiss)
##  [1] "Courtelary"   "Delemont"     "Franches-Mnt" "Moutier"      "Neuveville"  
##  [6] "Porrentruy"   "Broye"        "Glane"        "Gruyere"      "Sarine"      
## [11] "Veveyse"      "Aigle"        "Aubonne"      "Avenches"     "Cossonay"    
## [16] "Echallens"    "Grandson"     "Lausanne"     "La Vallee"    "Lavaux"      
## [21] "Morges"       "Moudon"       "Nyone"        "Orbe"         "Oron"        
## [26] "Payerne"      "Paysd'enhaut" "Rolle"        "Vevey"        "Yverdon"     
## [31] "Conthey"      "Entremont"    "Herens"       "Martigwy"     "Monthey"     
## [36] "St Maurice"   "Sierre"       "Sion"         "Boudry"       "La Chauxdfnd"
## [41] "Le Locle"     "Neuchatel"    "Val de Ruz"   "ValdeTravers" "V. De Geneve"
## [46] "Rive Droite"  "Rive Gauche"
# Cmpute MDS
mds <- swiss %>%
  dist(method='euclidean') %>%          
  cmdscale() %>%
  as_tibble()
## Warning: The `x` argument of `as_tibble.matrix()` must have unique column names if `.name_repair` is omitted as of tibble 2.0.0.
## Using compatibility `.name_repair`.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_warnings()` to see where this warning was generated.
colnames(mds) <- c("Dim.1", "Dim.2")

mds %>% head(10)
## # A tibble: 10 x 2
##     Dim.1  Dim.2
##     <dbl>  <dbl>
##  1  37.0  -17.4 
##  2 -42.8  -14.7 
##  3 -51.1  -19.3 
##  4   7.72  -5.46
##  5  35.0    5.13
##  6 -44.2  -25.9 
##  7 -56.4    3.23
##  8 -61.3    1.00
##  9 -56.4  -12.3 
## 10 -47.5  -19.9
# Plot MDS
ggscatter(mds, x = "Dim.1", y = "Dim.2", 
          label = rownames(swiss),
          size = 1,
          repel = TRUE)

Create \(3\) groups using \(k\)-means clustering and color points by group.

# K-means clustering
clust <- kmeans(mds, 3)$cluster %>%
  as.factor()
mds <- mds %>%
  mutate(groups = clust)
# Plot and color by groups
ggscatter(mds, x = "Dim.1", y = "Dim.2", 
          label = rownames(swiss),
          color = "groups",
          palette = "jco",
          size = 1, 
          ellipse = TRUE,
          ellipse.type = "convex",
          repel = TRUE)

Non-metric MDS

Kruskal's non-metric multidimensional scaling

# Cmpute MDS
library(MASS)
## 
## Attaching package: 'MASS'
## The following object is masked from 'package:dplyr':
## 
##     select
mds <- swiss %>%
  dist('euclidean') %>%          
  isoMDS() %>%
  .$points %>%
  as_tibble()
## initial  value 5.463800 
## iter   5 value 4.499103
## iter   5 value 4.495335
## iter   5 value 4.492669
## final  value 4.492669 
## converged
colnames(mds) <- c("Dim.1", "Dim.2")


# Plot MDS
ggscatter(mds, x = "Dim.1", y = "Dim.2", 
          label = rownames(swiss),
          size = 1,
          repel = TRUE)

Sammon's non-linear mapping:

# Cmpute MDS
library(MASS)
mds <- swiss %>%
  dist() %>%          
  sammon() %>%
  .$points %>%
  as_tibble()
## Initial stress        : 0.01959
## stress after   0 iters: 0.01959
colnames(mds) <- c("Dim.1", "Dim.2")

# Plot MDS
ggscatter(mds, x = "Dim.1", y = "Dim.2", 
          label = rownames(swiss),
          size = 1,
          repel = TRUE)

Correlation matrix using Multidimensional Scaling

MDS can also be used to detect a hidder pattern in a correlation matrix, but it is easy to transform it to a measure of dissimilarity. Distance between objects can be calculated as 1 - res.cor.

Perform a classical MDS on the mtcars dataset and comment

res.cor <- cor(mtcars, method = "spearman")
mds.cor <- (1 - res.cor) %>%
  cmdscale() %>%
  as_tibble()
colnames(mds.cor) <- c("Dim.1", "Dim.2")
ggscatter(mds.cor, x = "Dim.1", y = "Dim.2", 
          size = 1,
          label = colnames(res.cor),
          repel = TRUE)

** Perform a PCA on the mtcars dataset. Comment the differences with MDS. What are the main differences between MDS and PCA**

FactoMineR::PCA(mtcars)

## **Results for the Principal Component Analysis (PCA)**
## The analysis was performed on 32 individuals, described by 11 variables
## *The results are available in the following objects:
## 
##    name               description                          
## 1  "$eig"             "eigenvalues"                        
## 2  "$var"             "results for the variables"          
## 3  "$var$coord"       "coord. for the variables"           
## 4  "$var$cor"         "correlations variables - dimensions"
## 5  "$var$cos2"        "cos2 for the variables"             
## 6  "$var$contrib"     "contributions of the variables"     
## 7  "$ind"             "results for the individuals"        
## 8  "$ind$coord"       "coord. for the individuals"         
## 9  "$ind$cos2"        "cos2 for the individuals"           
## 10 "$ind$contrib"     "contributions of the individuals"   
## 11 "$call"            "summary statistics"                 
## 12 "$call$centre"     "mean of the variables"              
## 13 "$call$ecart.type" "standard error of the variables"    
## 14 "$call$row.w"      "weights for the individuals"        
## 15 "$call$col.w"      "weights for the variables"

Pairwise distances between American cities

The UScitiesD dataset gives 'straight line' distances between \(10\) cities in the US.

?UScitiesD
cities <- UScitiesD

Just looking at the table does not provide any information about the underlying structure of the data (i.e. the position of each city on a map). We are going to apply MDS to recover the geographical structure.

df <- tibble::as_tibble(us.cities)
df <- df %>% filter(name %in% c("Atlanta GA", "Chicago IL", "Denver CO", "Houston TX", 
                                "Los Angeles CA", "Miami FL", "New York NY", "San Francisco CA",                                       "Seattle WA", "WASHINGTON DC"))

usa <- map_data("usa")
gg1 <- ggplot() + 
  geom_polygon(data = usa, aes(x=long, y = lat, group = group), fill = "NA", color = "blue") + 
  coord_fixed(1.3)

labs <- data.frame(
  long = df$long,
  lat = df$lat,
  names = df$name,
  stringsAsFactors = FALSE
  )  

gg1 + 
  geom_point(data = labs, aes(x = long, y = lat), color = "black", size = 5) +
  geom_point(data = labs, aes(x = long, y = lat), color = "yellow", size = 4) + 
  geom_text_repel(data = labs, aes(x=long , y=lat, label = names), size=3)

cities_mds <- cities %>%
  cmdscale() %>%
  scale() %>% 
  as_tibble()

colnames(cities_mds) <- c("Dim.1", "Dim.2")
cities_mds
## # A tibble: 10 x 2
##     Dim.1   Dim.2
##     <dbl>   <dbl>
##  1 -0.697  0.330 
##  2 -0.370 -0.787 
##  3  0.467 -0.0584
##  4 -0.156  1.32  
##  5  1.17   0.901 
##  6 -1.10   1.34  
##  7 -1.04  -1.20  
##  8  1.38   0.260 
##  9  1.30  -1.34  
## 10 -0.949 -0.775
us.cities.name <- c("Atlanta", "Chicago", "Denver", "Houston", 
                    "LA", "Miami", "NYC", "SF", "Seattle", "DC")

cities_mds_rev <- cities_mds
cities_mds_rev$Dim.1 <- -cities_mds$Dim.1
cities_mds_rev$Dim.2 <- -cities_mds$Dim.2


ggscatter(cities_mds, x = "Dim.1", y = "Dim.2", 
          label = us.cities.name,
          size = 1,
          repel = TRUE)

ggscatter(cities_mds_rev, x = "Dim.1", y = "Dim.2", 
          label = us.cities.name,
          size = 1,
          repel = TRUE)

** Plot the MDS representation of the pair-wise distances for the 10 US cities. Comment on the results. Is this the usual US map? **

p <- ggplot(data = cities_mds) + geom_point(mapping = aes(x=Dim.1 , y=Dim.2), color="yellow", size=8)
p <- p + geom_text(data = cities_mds, mapping = aes(x=Dim.1 , y=Dim.2), label = us.cities.name, size=3)
p <- p + labs(title = "MDS representation of pair-wise distances of 21 European cities")
p

The cities on the map are not in their expected locations. It seems the map is not only mirrored, but flipped (Australia's point of view in some way). Indeed, this is the proper time to point to this fact that MDS only tries to preserve the inter-object distances,and therefore there is nothing wrong with the map. By operations such as rotation of the map, the distances remain intact. The map must be rotated 180 degrees. It is possible to do so by multiplying the coordinates of each point into -1.

** Solve the issue above by rotating the figure the proper way**

p <- ggplot(data = cities_mds) + geom_point(mapping = aes(x=-Dim.1 , y=-Dim.2), color="yellow", size=8)
p <- p + geom_text(data = cities_mds, mapping = aes(x=-Dim.1 , y=-Dim.2), label = us.cities.name, size=3)
p <- p + labs(title = "MDS representation of pair-wise distances of 21 European cities")
p

Kernel PCA

Reminders

Recall that the principal components variables \(Z\) of a data matrix \(X\) can be computed from the inner-product (gram) matrix \(K=XX^\top\). In detail, we compute the eigen-decomposition of the double-centered version of the gram matrix \[ \tilde{K} = (I-M) K (I-M) = U D^2 U^\top, \] where \(M = \frac 1n \mathbf{1}\mathbf{1}^\top\) and \(Z = UD\). Kernel PCA mimics this proceduren interpreting the kernel matrix \(\mathbf K = (K(x_i,x_{i^{'}}))_{1 \leq i,i^{'} \leq n}\) as an inner-product matrix of the implicit features \(\langle \phi(x_i), \phi(x_{i^{'}}) \rangle\) and finding its eigen vectors.

With Python using reticulate in R

library(reticulate) 
use_python("/usr/local/bin/python") 
reticulate::py_config()
## python:         /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate/bin/python
## libpython:      /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate/lib/libpython3.6m.dylib
## pythonhome:     /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate:/Users/florianbourgey/Library/r-miniconda/envs/r-reticulate
## version:        3.6.10 |Anaconda, Inc.| (default, Mar 25 2020, 18:53:43)  [GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)]
## numpy:          /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate/lib/python3.6/site-packages/numpy
## numpy_version:  1.19.2
py_install("sklearn", pip=TRUE) # install package sklearn 
py_install("matplotlib", pip=TRUE) # install matplotlib

Let us start with a simple example of 2 half-moon shapes generated by the make_moons functions from sklearn_learn.

import matplotlib.pyplot as plt
from sklearn.datasets import make_moons

X, y = make_moons(n_samples=100, random_state=123)

plt.figure(figsize=(8,6))

plt.scatter(X[y==0, 0], X[y==0, 1], color='red', alpha=0.5)
plt.scatter(X[y==1, 0], X[y==1, 1], color='blue', alpha=0.5)

plt.title('A nonlinear 2Ddataset')
plt.ylabel('y coordinate')
plt.xlabel('x coordinate')

plt.show()

Are the two half-moon shapes linearly separable? Do you expect PCA to give satisfactory results? Perform a PCA using sklearn with n_components= \(1\) and \(2\). Comment.

from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
import numpy as np

X, y = make_moons(n_samples=100, random_state=123)
scikit_pca = PCA(n_components=1)
X_spca = scikit_pca.fit_transform(X)

plt.figure(figsize=(8,6))
plt.scatter(X_spca[y==0, 0], np.zeros((50,1)), color='red', alpha=0.5)
plt.scatter(X_spca[y==1, 0], np.zeros((50,1)), color='blue', alpha=0.5)

plt.title('First principal component after Linear PCA')
plt.xlabel('PC1')

plt.show()

Since the two half-moon shapes are linearly inseparable, we expect that the 'classic' PCA will fail to give us a 'good' representation of the data in 1D space. As we can see, the resulting principal components do not yield a subspace where the data is linearly separated well.

Perform a KernelPCA using the KernelPCA function from sklearn with the rbf kernel and \(\gamma=15\) with both n_components=\(1\) and \(2\).Comment. Try different values for \(\gamma\) and different kernels. Comment.

from sklearn.decomposition import KernelPCA
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
import numpy as np

gamma = 15
X, y = make_moons(n_samples=100, random_state=123)
X_pc = KernelPCA(gamma=15, n_components=1, kernel='rbf').fit_transform(X)

plt.figure(figsize=(8,6))
plt.scatter(X_pc[y==0, 0], np.zeros((50,1)), color='red', alpha=0.5)
plt.scatter(X_pc[y==1, 0], np.zeros((50,1)), color='blue', alpha=0.5)

plt.title('First principal components after RBF Kernel PCA with $\gamma$={}'.format(gamma))
plt.xlabel('PC1')
plt.show()

from sklearn.decomposition import KernelPCA
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
import numpy as np

gamma = 15
X, y = make_moons(n_samples=100, random_state=123)
X_pc = KernelPCA(gamma=15, n_components=2, kernel='rbf').fit_transform(X)

plt.figure(figsize=(8,6))
plt.scatter(X_pc[y==0, 0], X_pc[y==0, 1], color='red', alpha=0.5)
plt.scatter(X_pc[y==1, 0], X_pc[y==1, 1], color='blue', alpha=0.5)

plt.title('First 2 principal components after RBF Kernel PCA with $\gamma$={}'.format(gamma))
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.show()

Concentric circles

Another well-known example for which linear PCA will fail is the classic case of \(2\) concentric circles with random noise produced by scikit-learn???s make_circles.

from sklearn.datasets import make_circles
import matplotlib.pyplot as plt
import numpy as np

X, y = make_circles(n_samples=1000, random_state=123, noise=0.1, factor=0.2)

plt.figure(figsize=(8,6))

plt.scatter(X[y==0, 0], X[y==0, 1], color='red', alpha=0.5)
plt.scatter(X[y==1, 0], X[y==1, 1], color='blue', alpha=0.5)
plt.title('Concentric circles')
plt.ylabel('y coordinate')
plt.xlabel('x coordinate')
plt.show()

Perform both a linear and kernel PCA on the make_circles dataset

Swiss roll

Unrolling the Swiss roll is a much more challenging task (see Swiss roll).

from sklearn.datasets import make_swiss_roll
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt

X, color = make_swiss_roll(n_samples=800, random_state=123)

fig = plt.figure(figsize=(7,7))
ax = fig.add_subplot(111, projection='3d')
ax.scatter(X[:, 0], X[:, 1], X[:, 2], c=color, cmap=plt.cm.rainbow)
plt.title('Swiss Roll in 3D')
plt.show()

Again, try to perform both linear and kernel PCA on the Swiss roll dataset. Try with different values of \(\gamma\). Are you satisfied with the results?

from sklearn.datasets import make_swiss_roll
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
from sklearn.decomposition import KernelPCA

gamma = 0.05
X, color = make_swiss_roll(n_samples=800, random_state=123)
X_pc = KernelPCA(gamma=gamma, n_components=2, kernel='rbf').fit_transform(X)

plt.figure(figsize=(8,6))
plt.scatter(X_pc[:, 0], X_pc[:, 1], c=color, cmap=plt.cm.rainbow)

plt.title('First 2 principal components after RBF Kernel PCA with $\gamma$={}'.format(gamma))
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.show()

Locally Linear Embedding

In 2000, Sam T. Roweis and Lawrence K. Saul Nonlinear dimensionality reduction by locally linear embedding introduced an unsupervised learning algorithm called locally linear embedding (LLE) that is better suited to identify patterns in the high-dimensional feature space and solves our problem of nonlinear dimensionality reduction for the Swiss roll.

Locally linear embedding (LLE) seeks a lower-dimensional projection of the data which preserves distances within local neighborhoods. It can be thought of as a series of local Principal Component Analyses which are globally compared to find the best non-linear embedding.

Using the locally_linear_embedding class, 'unroll' the Swiss roll both in \(1\) and \(2\) dimensions.

import numpy as np
from sklearn.datasets import make_swiss_roll
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
from sklearn.manifold import locally_linear_embedding

X, color = make_swiss_roll(n_samples=800, random_state=123)
X_lle, err = locally_linear_embedding(X, n_neighbors=12, n_components=1)

plt.figure(figsize=(8,6))
plt.scatter(X_lle, np.zeros((800,1)), c=color, cmap=plt.cm.rainbow)

plt.title('First principal component after Locally Linear Embedding')
plt.show()